-
Notifications
You must be signed in to change notification settings - Fork 869
feat: add Levo AI observability plugin with response headers capture #1494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add Levo AI observability plugin with response headers capture #1494
Conversation
This commit introduces a production-ready observability plugin for Levo AI
that captures complete LLM request/response data and sends it to Levo
Collector in OpenTelemetry (OTLP) format.
## Plugin Features
- Captures complete request data (headers, body, metadata)
- Captures complete response data (headers, body, status, tokens)
- Sends 2 OTLP spans per trace (REQUEST_ROOT + RESPONSE_ROOT)
- Multi-tenant routing via x-levo-organization-id and x-levo-workspace-id headers
- Configurable endpoint, timeout, and custom headers
- Graceful error handling and streaming request detection
## Critical Bug Fix
Fixed missing response headers in HookSpanContext:
- Added headers field to HookSpanContextResponse interface
- Modified afterRequestHookHandler to extract and pass response headers
- Enables all observability plugins to access complete response data
## Universal Improvements
Changes to hooks system benefit ALL plugins:
1. Response headers now captured in HookSpanContext (was undefined)
2. afterRequestHook plugins receive complete response metadata
3. Backward compatible (headers field is optional)
4. Aligns with existing pattern (request headers already captured)
## Files Added
- plugins/levo-ai/index.ts - Plugin implementation (404 lines)
- plugins/levo-ai/manifest.json - Plugin metadata and configuration
- plugins/levo-ai/README.md - Professional documentation (195 lines)
## Files Modified
- plugins/index.ts - Register levo.observability plugin
- src/middlewares/hooks/types.ts - Add headers to HookSpanContextResponse
- src/middlewares/hooks/index.ts - Accept and store response headers
- src/handlers/responseHandlers.ts - Extract response headers from Response object
## Data Captured (Verified End-to-End)
Request:
- All headers (9+ including custom headers)
- Complete JSON body with all parameters
- Provider and request type metadata
Response (Critical Fix):
- All headers (15+ including x-request-id, rate-limits, LLM metadata)
- Complete JSON body (choices, usage, model info)
- HTTP status code
- Token usage (prompt, completion, total)
## Usage
```json
{
"provider": "openai",
"api_key": "your-key",
"after_request_hooks": [{
"id": "levo.observability",
"endpoint": "http://collector:4318/v1/traces",
"organizationId": "your-org-id",
"workspaceId": "your-workspace-id"
}]
}
```
## Testing
- End-to-end validation performed with real gateway and mock services
- Complete OTLP payload verified
- All request/response data capture validated
- Build successful, no linter errors
- 100% backward compatible
Closes: Integration of Levo AI observability with Portkey Gateway
|
@Yamparala-Venkata-Gopi not exposing headers to plugins has been a conscious decision to avoid leaking credentials, I'm not in favor of making this change |
|
@narengogi I appreciate the security concern, but I'm trying to understand the inconsistency I'm seeing in the codebase. Question 1: How do other observability integrations work? Could you please help us understand how you support observability integrations that require complete request/response data? For example:
What pattern should we follow? Question 2: Headers are already captured in your logs I see that your File: export interface LogObject {
transformedRequest: {
body: any;
headers: Record<string, string>; // ← Headers captured here
};
// ...
}File: transformedRequest: {
body: requestContext.transformedRequestBody,
headers: fetchOptions.headers, // ← All request headers stored
}File: const log: LogObject = {
request: {
headers: options.headers, // ← Request headers
},
response: {
headers: responseHeaders, // ← Response headers too!
}
}Question 3: Your own plugins access headers Your PANW AIRS plugin accesses request headers: File: const traceId =
ctx?.request?.headers?.['x-portkey-trace-id'] || // ← Accessing headers
...My confusion: If headers are a security concern for plugins, why are they:
What Levo needs: We only need response headers (not request headers with API keys):
These are the same headers your Proposed solution: Could we expose response headers to plugins using the same pattern as your This would align with:
Looking forward to your guidance on the right approach! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR integrates Levo AI observability into Portkey Gateway by adding a new plugin that captures and sends LLM request/response data to Levo Collector in OTLP format. The implementation includes a critical fix to the hooks system that enables response headers capture for all observability plugins.
Changes:
- Added Levo AI observability plugin with OTLP trace generation
- Fixed missing response headers in HookSpanContext (previously undefined)
- Enhanced hook normalization to support plugin-style configuration format
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/levo-ai/index.ts | Core plugin implementation with OTLP conversion and trace generation |
| plugins/levo-ai/manifest.json | Plugin metadata, configuration schema, and parameter definitions |
| plugins/levo-ai/README.md | Comprehensive documentation with usage examples and troubleshooting |
| plugins/index.ts | Registered levo.observability plugin in the plugins registry |
| src/middlewares/hooks/types.ts | Added optional headers field to HookSpanContextResponse interface |
| src/middlewares/hooks/index.ts | Added hook normalization logic and response headers propagation |
| src/handlers/responseHandlers.ts | Extracted response headers from Response object for hooks |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| console.log( | ||
| `[HooksManager] executeHooks: spanId=${spanId}, eventTypePresets=${eventTypePresets.join(',')}, hooksToExecute=${hooksToExecute.length}` | ||
| ); | ||
| if (hooksToExecute.length > 0) { | ||
| console.log( | ||
| `[HooksManager] Hooks to execute:`, | ||
| hooksToExecute.map((h) => ({ | ||
| id: h.id, | ||
| type: h.type, | ||
| checks: h.checks?.length || 0, | ||
| })) | ||
| ); | ||
| } |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug console.log statements should be removed from production code or replaced with proper logging infrastructure. These statements will clutter production logs and may expose sensitive internal state.
| console.log( | ||
| `[HooksManager] ⚠️ Hook ${hook.id} has no checks array! Hook structure:`, | ||
| JSON.stringify(hook, null, 2) |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug console.log statements should be removed from production code. The JSON.stringify of the entire hook structure could potentially log sensitive data and create verbose logs in production.
| console.log( | |
| `[HooksManager] ⚠️ Hook ${hook.id} has no checks array! Hook structure:`, | |
| JSON.stringify(hook, null, 2) | |
| console.warn( | |
| `[HooksManager] Hook ${hook.id} has no checks array configured.` |
| const hookType = type || HookType.GUARDRAIL; | ||
| // All other properties become parameters | ||
| return { | ||
| id: `hook_${Math.random().toString(36).substring(2, 9)}`, |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Math.random() for generating IDs is not cryptographically secure and may produce collisions. Consider using a UUID library or crypto.randomUUID() for generating unique hook identifiers.
| * Generate a random 32-character hex trace ID (OTLP format) | ||
| */ | ||
| function generateTraceId(): string { | ||
| const bytes = new Uint8Array(16); | ||
| if (typeof crypto !== 'undefined' && crypto.getRandomValues) { | ||
| crypto.getRandomValues(bytes); | ||
| } else { | ||
| // Fallback for environments without crypto | ||
| for (let i = 0; i < bytes.length; i++) { | ||
| bytes[i] = Math.floor(Math.random() * 256); | ||
| } | ||
| } | ||
| return Array.from(bytes) | ||
| .map((b) => b.toString(16).padStart(2, '0')) | ||
| .join(''); | ||
| } | ||
|
|
||
| /** |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function generateTraceId is defined but never called. The code uses generateTraceIdFromString instead. This unused function should either be removed or renamed to clarify its purpose versus generateTraceIdFromString.
| * Generate a random 32-character hex trace ID (OTLP format) | |
| */ | |
| function generateTraceId(): string { | |
| const bytes = new Uint8Array(16); | |
| if (typeof crypto !== 'undefined' && crypto.getRandomValues) { | |
| crypto.getRandomValues(bytes); | |
| } else { | |
| // Fallback for environments without crypto | |
| for (let i = 0; i < bytes.length; i++) { | |
| bytes[i] = Math.floor(Math.random() * 256); | |
| } | |
| } | |
| return Array.from(bytes) | |
| .map((b) => b.toString(16).padStart(2, '0')) | |
| .join(''); | |
| } | |
| /** |
| // hookSpanId might not be on context, use a fallback | ||
| const hookSpanId = | ||
| context.hookSpanId || | ||
| context.metadata?.hookSpanId || | ||
| `span-${Date.now()}`; | ||
| const otlpTrace = convertToOTLP(context, hookSpanId); |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The variable name hookSpanId is misleading here as it's used as a seed for generating the traceId, not as an actual span identifier. Consider renaming to traceSeed or traceIdSeed for clarity.
| // hookSpanId might not be on context, use a fallback | |
| const hookSpanId = | |
| context.hookSpanId || | |
| context.metadata?.hookSpanId || | |
| `span-${Date.now()}`; | |
| const otlpTrace = convertToOTLP(context, hookSpanId); | |
| // Derive a trace ID seed from the hook context, with a fallback | |
| const traceIdSeed = | |
| context.hookSpanId || | |
| context.metadata?.hookSpanId || | |
| `trace-seed-${Date.now()}`; | |
| const otlpTrace = convertToOTLP(context, traceIdSeed); |
| const headers: Record<string, string> = parameters?.headers | ||
| ? JSON.parse(parameters.headers) | ||
| : {}; |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JSON.parse can throw an error if parameters.headers contains invalid JSON. This error should be caught and provide a clear error message indicating the headers parameter must be valid JSON.
| const headers: Record<string, string> = parameters?.headers | |
| ? JSON.parse(parameters.headers) | |
| : {}; | |
| let headers: Record<string, string> = {}; | |
| if (parameters?.headers) { | |
| try { | |
| const parsed = JSON.parse(parameters.headers); | |
| if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { | |
| headers = parsed as Record<string, string>; | |
| } else { | |
| throw new Error( | |
| 'Invalid headers parameter: must be valid JSON representing an object.' | |
| ); | |
| } | |
| } catch { | |
| throw new Error( | |
| 'Invalid headers parameter: must be valid JSON string representing an object.' | |
| ); | |
| } | |
| } |
|
@narengogi @VisargD could you please review the changes, would like to get it merged |
This commit introduces a production-ready observability plugin for Levo AI that captures complete LLM request/response data and sends it to Levo Collector in OpenTelemetry (OTLP) format.
Plugin Features
Critical Bug Fix
Fixed missing response headers in HookSpanContext:
Universal Improvements
Changes to hooks system benefit ALL plugins:
Files Added
Files Modified
Data Captured (Verified End-to-End)
Request:
Response (Critical Fix):
Usage
{ "provider": "openai", "api_key": "your-key", "after_request_hooks": [{ "id": "levo.observability", "endpoint": "http://collector:4318/v1/traces", "organizationId": "your-org-id", "workspaceId": "your-workspace-id" }] }Testing
Closes: Integration of Levo AI observability with Portkey Gateway
Description: (required)
Tests Run/Test cases added: (required)
Type of Change: